iT邦幫忙

2023 iThome 鐵人賽

0
自我挑戰組

Techschool Goalng Backend Master Class 的學習記錄系列 第 28

[Day 28] How to handle DB errors in Golang correctly

  • 分享至 

  • xImage
  •  

Generate User Creation and Retrieval Code

步驟1: 建立 user.sql 檔案

  1. db/query資料夾中創建一個新檔案名為user.sql
  2. 此檔案將包含用於建立和獲取用戶的兩個SQL查詢。

步驟2: 從 account.sql 檔案複製查詢

  1. account.sql檔案中複製以下查詢:

    -- name: CreateAccount :one
    INSERT INTO accounts (
      owner,
      balance,
      currency
    ) VALUES (
      $1, $2, $3
    ) RETURNING *;
    
    -- name: GetAccount :one
    SELECT * FROM accounts
    WHERE id = $1 LIMIT 1;
    
    
  2. 將其貼到新建立的user.sql檔案中。

步驟3: 調整查詢以適用於 users 表

  1. 改變CreateAccount函數名稱為CreateUser

  2. 修改表格名稱為users

  3. 更新欄位名為:username, hashed_password, full_name, 和 email

  4. 不需指定password_changed_atcreated_at欄位,因為Postgres會自動以預設值填充這些欄位。

  5. 在值列表中添加一個額外的參數,如下:

    -- name: CreateUser :one
    INSERT INTO users (
      username,
      hashed_password,
      full_name,
      email
    ) VALUES (
      $1, $2, $3, $4
    ) RETURNING *;
    
    

步驟4: 修改 GetAccount 函數

  1. GetAccount函數名稱改為GetUser

  2. 查詢更改為從users表中選擇,並根據username獲取用戶資訊,如下:

    -- name: GetUser :one
    SELECT * FROM users
    WHERE username = $1 LIMIT 1;
    
    

步驟5: 生成 Golang 代碼

  1. 開啟終端機並運行以下命令以生成 Golang 代碼:

    ❯ make sqlc
    sqlc generate
    
    
  2. 回到Visual Studio Code,你會發現db/sqlc/models.go文件中新增了一個新的User結構。

  3. 會看到一個新檔案db/sqlc/user.sql.go,其中包含了兩個函數,用於從資料庫建立和獲取用戶。

Writing Tests for Generated Functions

Step 1: 建立user_test.go檔案

  1. db/sqlc資料夾中創建一個新檔案名為user_test.go
  2. 複製我們為createget Account編寫的測試,然後將它們貼到此檔案中。

Step 2: 建立createRandomUser函數

  1. 將函數名稱更改為createRandomUser

  2. 設置參數類型為CreateUserParams

  3. 函數實作如下:

    func createRandomUser(t *testing.T) User {
        arg := CreateUserParams{
            Username:       util.RandomOwner(),
            HashedPassword: "secret",
            FullName:       util.RandomOwner(),
            Email:          util.RandomEmail(),
        }
    
        ...
    }
    
    

Step 3: 添加RandomEmail函數

  1. util/random.go檔案中實現一個新的RandomEmail函數,如下:

    // RandomEmail generates a random email
    func RandomEmail() string {
        return fmt.Sprintf("%s@email.com", RandomString(6))
    }
    
    
  2. 此函數會返回一個隨機生成的電子郵件地址。

Step 4: 完善createRandomUser函數

  1. 回到測試檔案,更改函數調用為testQueries.CreateUser(),並檢查輸出結果是否為一個用戶物件,函數實作如下:

    func createRandomUser(t *testing.T) User {
        ...
    
        user, err := testQueries.CreateUser(context.Background(), arg)
        require.NoError(t, err)
        require.NotEmpty(t, user)
    
        require.Equal(t, arg.Username, user.Username)
        require.Equal(t, arg.HashedPassword, user.HashedPassword)
        require.Equal(t, arg.FullName, user.FullName)
        require.Equal(t, arg.Email, user.Email)
        require.NotZero(t, user.CreatedAt)
        require.True(t, user.PasswordChangedAt.IsZero())
    
        return user
    }
    
    
  2. 這裡我們比較輸出用戶的每個欄位與輸入參數,並驗證CreatedAtPasswordChangedAt欄位的值。

  3. 確認 user.PasswordChangedAt 初始為zero timestamp(使用 IsZero() 函數進行檢查)。

Step 5: 編寫測試

  1. TestCreateUser測試函數只需調用createRandomUser函數,如下:

    func TestCreateUser(t *testing.T) {
        createRandomUser(t)
    }
    
    
  2. 接著為GetUser功能編寫測試,如下:

    func TestGetUser(t *testing.T) {
        user1 := createRandomUser(t)
        user2, err := testQueries.GetUser(context.Background(), user1.Username)
        require.NoError(t, err)
        require.NotEmpty(t, user2)
    
        require.Equal(t, user1.Username, user2.Username)
        require.Equal(t, user1.HashedPassword, user2.HashedPassword)
        require.Equal(t, user1.FullName, user2.FullName)
        require.Equal(t, user1.Email, user2.Email)
        require.WithinDuration(t, user1.PasswordChangedAt, user2.PasswordChangedAt, time.Second)
        require.WithinDuration(t, user1.CreatedAt, user2.CreatedAt, time.Second)
    }
    
    
  3. 在這裡,我們確保查詢的輸出(user2)與輸入(user1)匹配。

Step 6: 執行測試

  1. 首先運行TestCreateUser測試。

    Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestCreateUser$ github.com/Kcih4518/simpleBank_2023/db/sqlc
    
    ok  	github.com/Kcih4518/simpleBank_2023/db/sqlc	0.378s
    
  2. 接著運行TestGetUser測試。

    Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestGetUser$ github.com/Kcih4518/simpleBank_2023/db/sqlc
    
    ok  	github.com/Kcih4518/simpleBank_2023/db/sqlc
    
  3. 最後使用TablePlus看數據庫中的User Table的Records

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746rbgBXerJxs.png

Fix the failed tests

  • db/sqlc/account_test.go文件中,當前代碼僅生成一個隨機擁有者,並未與任何現有用戶建立連接。

    func createRandomAccount(t *testing.T) Account {
        arg := CreateAccountParams{
            Owner:    util.RandomOwner(),
            Balance:  util.RandomMoney(),
            Currency: util.RandomCurrency(),
        }
        ...
    }
    
  • 為了修復此問題,我們首先需要在數據庫中創建一名用戶。然後,我們會使用新創建的用戶的用戶名作為賬戶擁有者,而不是一個隨機擁有者。

    func createRandomAccount(t *testing.T) Account {
        user := createRandomUser(t)
    
        arg := CreateAccountParams{
            Owner:    user.Username,
            Balance:  util.RandomMoney(),
            Currency: util.RandomCurrency(),
        }
        ...
    }
    
  • run package tests

    Running tool: /usr/local/go/bin/go test -timeout 30s -coverprofile=/var/folders/sk/r57q522d46v1h2f7xv98l7mh0000gn/T/vscode-goCJj4SZ/go-code-cover github.com/Kcih4518/simpleBank_2023/db/sqlc
    
    ok  	github.com/Kcih4518/simpleBank_2023/db/sqlc	0.654s	coverage: 80.8% of statements
    
  • 但是當執行API testing 時會出現以下錯誤:

    • 我們在這裡遇到一個錯誤,因為我們的**MockStore未實現db.Store介面。它缺少一些功能的實現。這是因為我們之前運行make sqlc生成代碼時,CreateUserGetUser兩個新函數已被添加到Querier**介面。而介面是介面的一部分。
    api/account.go:1: : # github.com/Kcih4518/simpleBank_2023/api [github.com/Kcih4518/simpleBank_2023/api.test]
    api/account_test.go:143:24: cannot use store (variable of type *mockdb.MockStore) as db.Store value in argument to NewServer: *mockdb.MockStore does not implement db.Store (missing method CreateUser)
    api/account_test.go:233:24: cannot use store (variable of type *mockdb.MockStore) as db.Store value in argument to NewServer: *mockdb.MockStore does not implement db.Store (missing method CreateUser)
    api/account_test.go:343:24: cannot use store (variable of type *mockdb.MockStore) as db.Store value in argument to NewServer: *mockdb.MockStore does not implement db.Store (missing method CreateUser)
    api/account_test.go:427:24: cannot use store (variable of type *mockdb.MockStore) as db.Store value in argument to NewServer: *mockdb.MockStore does not implement db.Store (missing method CreateUser)
    api/account_test.go:544:24: cannot use store (variable of type *mockdb.MockStore) as db.Store value in argument to NewServer: *mockdb.MockStore does not implement db.Store (missing method CreateUser)
    api/transfer_test.go:209:24: cannot use store (variable of type *mockdb.MockStore) as db.Store value in argument to NewServer: *mockdb.MockStore does not implement db.Store (missing method CreateUser) (typecheck)
    
  • Solution:

    1. 重新生成MockStore代碼

      • 命令:

        shCopy code
        ❯ make mock
        mockgen -package mockdb -destination db/mock/store.go github.com/techschool/simplebank/db/sqlc Store
        
        
    2. 檢查新生成的代碼

      • 在**db/mock/store.go文件中,您可以看到已添加GetUserCreateUser**函數的實現。

      • **CreateUser**函數模擬和預期呼叫的代碼結構如下:

        goCopy code
        // CreateUser mocks base method
        func (m *MockStore) CreateUser(arg0 context.Context, arg1 db.CreateUserParams) (db.User, error) {
            ...
        }
        
        // CreateUser indicates an expected call of CreateUser
        func (mr *MockStoreMockRecorder) CreateUser(arg0, arg1 interface{}) *gomock.Call {
            ...
        }
        
        
      • **GetUser**函數模擬和預期呼叫的代碼結構如下:

        goCopy code
        // GetUser mocks base method
        func (m *MockStore) GetUser(arg0 context.Context, arg1 string) (db.User, error) {
            ...
        }
        
        // GetUser indicates an expected call of GetUser
        func (mr *MockStoreMockRecorder) GetUser(arg0, arg1 interface{}) *gomock.Call {
            ...
        }
        
        
  • 重新運行API單元測試

    • 現在API單元測試應該可以運行。讓我們在終端中重新運行**make test**來確認。

      make test
      

      https://ithelp.ithome.com.tw/upload/images/20231017/20121746MybTpf97XF.png

Handle different types of DB error

Try to run the HTTP server

伺服器運行後,將在8080端口上監聽和服務HTTP請求。然後,使用Postman測試現有的API以創建一個新的帳戶。

make server

go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:	export GIN_MODE=release
 - using code:	gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /accounts                 --> github.com/Kcih4518/simpleBank_2023/api.(*Server).createAccount-fm (3 handlers)
[GIN-debug] GET    /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).getAccount-fm (3 handlers)
[GIN-debug] DELETE /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).delAccount-fm (3 handlers)
[GIN-debug] GET    /accounts                 --> github.com/Kcih4518/simpleBank_2023/api.(*Server).listAccount-fm (3 handlers)
[GIN-debug] PATCH  /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).updateAccount-fm (3 handlers)
[GIN-debug] POST   /transfers                --> github.com/Kcih4518/simpleBank_2023/api.(*Server).createTransfer-fm (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on 0.0.0.0:8080

Testing create an account

首次測試將嘗試為不存在於資料庫中的擁有者創建一個帳戶。

  • 遇到的錯誤

    1. 因為違反了帳戶擁有者的外鍵約束,我們收到了一個錯誤。HTTP響應狀態碼為500內部服務器錯誤,這不是非常適合,因為錯誤是由客戶端造成的。

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746kFYRzhjEek.png

    1. 較好的做法是返回403禁止狀態,這需要我們處理Postgres返回的錯誤。

更新錯誤處理代碼

api/account.go文件的創建帳戶處理程序中,如果store.CreateAccount調用返回錯誤,我們將嘗試將其轉換為pq.Error類型,並將結果分配給pqErr變量。如果轉換成功,我們將在此處打印日誌以查看此錯誤的代碼名稱:

func (server *Server) createAccount(ctx *gin.Context) {
    ...

    account, err := server.store.CreateAccount(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            log.Println(pqErr.Code.Name())
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, account)
}

接著,重新啟動服務器,並通過Postman重新發送相同的請求。

  • 日誌中的錯誤代碼名稱
    1. foreign_key_violation:當我們嘗試為不存在的用戶創建新帳戶時出現。

      make server
      
      go run main.go
      [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
      
      [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
       - using env:	export GIN_MODE=release
       - using code:	gin.SetMode(gin.ReleaseMode)
      
      [GIN-debug] POST   /accounts                 --> github.com/Kcih4518/simpleBank_2023/api.(*Server).createAccount-fm (3 handlers)
      [GIN-debug] GET    /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).getAccount-fm (3 handlers)
      [GIN-debug] DELETE /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).delAccount-fm (3 handlers)
      [GIN-debug] GET    /accounts                 --> github.com/Kcih4518/simpleBank_2023/api.(*Server).listAccount-fm (3 handlers)
      [GIN-debug] PATCH  /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).updateAccount-fm (3 handlers)
      [GIN-debug] POST   /transfers                --> github.com/Kcih4518/simpleBank_2023/api.(*Server).createTransfer-fm (3 handlers)
      [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
      Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
      [GIN-debug] Listening and serving HTTP on 0.0.0.0:8080
      2023/09/19 19:45:08 foreign_key_violation
      [GIN] 2023/09/19 - 19:45:08 | 500 |   13.885157ms |             ::1 | POST     "/accounts"
      
    2. unique_violation:當我們嘗試為同一擁有者創建超過一個具有相同貨幣的帳戶時出現。

      make server
      go run main.go
      [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
      
      [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
       - using env:	export GIN_MODE=release
       - using code:	gin.SetMode(gin.ReleaseMode)
      
      [GIN-debug] POST   /accounts                 --> github.com/Kcih4518/simpleBank_2023/api.(*Server).createAccount-fm (3 handlers)
      [GIN-debug] GET    /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).getAccount-fm (3 handlers)
      [GIN-debug] DELETE /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).delAccount-fm (3 handlers)
      [GIN-debug] GET    /accounts                 --> github.com/Kcih4518/simpleBank_2023/api.(*Server).listAccount-fm (3 handlers)
      [GIN-debug] PATCH  /accounts/:id             --> github.com/Kcih4518/simpleBank_2023/api.(*Server).updateAccount-fm (3 handlers)
      [GIN-debug] POST   /transfers                --> github.com/Kcih4518/simpleBank_2023/api.(*Server).createTransfer-fm (3 handlers)
      [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
      Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
      [GIN-debug] Listening and serving HTTP on 0.0.0.0:8080
      2023/09/19 19:49:10 unique_violation
      [GIN] 2023/09/19 - 19:49:10 | 500 |   18.354167ms |             ::1 | POST     "/accounts"
      

更新錯誤處理代碼

現在,我們將更新錯誤處理代碼以處理這些特定錯誤情況。我們將使用一個簡單的switch case語句來檢查錯誤代碼名稱,並根據情況返回相應的HTTP狀態代碼:

func (server *Server) createAccount(ctx *gin.Context) {
    ...

    account, err := server.store.CreateAccount(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "foreign_key_violation", "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, account)
}

測試更新後的錯誤處理

  • 403 Forbidden :

    {
    "error": "pq: insert or update on table \"accounts\" violates foreign key constraint \"accounts_owner_fkey\""
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746xB7rz5JQBu.png

  • 403 Forbidden

    {
        "error": "pq: duplicate key value violates unique constraint \"accounts_owner_currency_idx\""
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746WPbjvwjxnq.png


上一篇
[Day 27] Add users table with unique & foreign key constraints in PostgreSQL
下一篇
[Day 29] How to securely store passwords?
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言